第 10 章 Spring Boot 综合项目实战——瑞吉外卖
写给新手的话:
前面九章,我们学了 Spring Boot 的各种功能:Web、数据访问、缓存、安全、消息、任务调度...
这一章,我们把这些知识综合起来,做一个完整的项目:瑞吉外卖。
这是一个真实的外卖项目,包括后台管理系统和用户端手机网站。
通过这个项目,你会学到:
- 一个完整的 Spring Boot 项目长什么样
- 企业级项目的代码结构和规范
- 各种技术怎么组合在一起用
- 实际开发中的一些技巧和最佳实践
学习建议:
- 先整体了解项目结构,再逐个模块深入
- 重点理解设计思想,而不只是代码
- 多思考:为什么这么设计?有没有更好的方式?
- 最好自己动手敲一遍,印象更深
准备好了吗?我们开始做项目吧!
10.1 项目概述
10.1.1 项目介绍
瑞吉外卖是一个外卖点餐系统,类似美团、饿了么的简化版。
两大模块:
后台管理系统(给商家用)
- 员工管理:员工登录、退出、增删改查
- 分类管理:菜品分类、套餐分类
- 菜品管理:菜品的增删改查、上下架、口味管理
- 套餐管理:套餐的增删改查、上下架
- 订单管理:查看订单
用户端(给消费者用)
- 用户登录:手机号 + 验证码
- 地址管理:收货地址的增删改查
- 菜品展示:浏览菜品、套餐
- 购物车:添加、减少、清空
- 下单:提交订单
10.1.2 技术栈
| 技术 | 版本 | 说明 |
|---|---|---|
| Spring Boot | 2.7.6 | 基础框架 |
| Spring MVC | - | Web 层 |
| MyBatis-Plus | 3.4.2 | ORM 框架 |
| MySQL | 8.x | 数据库 |
| Redis | - | 缓存 |
| Spring Cache | - | 缓存抽象 |
| Lombok | 1.18.20 | 简化代码 |
| Druid | 1.1.23 | 数据源 |
| FastJSON | 1.2.76 | JSON 处理 |
| Apache Commons Lang | 2.6 | 工具类 |
10.1.3 项目结构
reggie
├── src/main/java/com/itheima/reggie
│ ├── ReggieApplication.java # 启动类
│ ├── common # 公共类
│ │ ├── R.java # 统一返回结果
│ │ ├── BaseContext.java # 线程上下文
│ │ ├── CustomException.java # 自定义异常
│ │ ├── GlobalExceptionHandler.java # 全局异常处理
│ │ ├── JacksonObjectMapper.java # JSON 序列化配置
│ │ └── MyMetaObjectHandler.java # 自动填充
│ ├── config # 配置类
│ │ ├── WebMvcConfig.java # Web MVC 配置
│ │ └── MybatisPlusConfig.java # MyBatis-Plus 配置
│ ├── filter # 过滤器
│ │ └── LoginCheckFilter.java # 登录检查过滤器
│ ├── entity # 实体类
│ │ ├── Employee.java
│ │ ├── Category.java
│ │ ├── Dish.java
│ │ ├── DishFlavor.java
│ │ ├── Setmeal.java
│ │ ├── SetmealDish.java
│ │ ├── User.java
│ │ ├── AddressBook.java
│ │ ├── ShoppingCart.java
│ │ ├── Orders.java
│ │ └── OrderDetail.java
│ ├── dto # 数据传输对象
│ │ ├── DishDto.java
│ │ ├── SetmealDto.java
│ │ └── OrdersDto.java
│ ├── mapper # Mapper 接口
│ │ └── ...
│ ├── service # 业务层
│ │ ├── impl # 实现类
│ │ └── ...
│ ├── controller # 控制层
│ │ ├── EmployeeController.java
│ │ ├── CategoryController.java
│ │ ├── DishController.java
│ │ ├── SetmealController.java
│ │ ├── UserController.java
│ │ ├── AddressBookController.java
│ │ ├── ShoppingCartController.java
│ │ ├── OrderController.java
│ │ ├── OrderDetailController.java
│ │ └── CommonController.java
│ └── utils # 工具类
│ └── ValidateCodeUtils.java
└── src/main/resources
├── application.yml # 配置文件
├── backend/ # 后台管理前端页面
└── front/ # 用户端前端页面三层架构:
- Controller:接收请求,返回结果
- Service:业务逻辑
- Mapper / DAO:数据访问
标准的 MVC 三层架构,企业项目基本都是这个结构。
10.1.4 数据库表
主要的数据库表:
| 表名 | 说明 |
|---|---|
| employee | 员工表 |
| category | 分类表(菜品分类、套餐分类) |
| dish | 菜品表 |
| dish_flavor | 菜品口味表 |
| setmeal | 套餐表 |
| setmeal_dish | 套餐菜品关联表 |
| user | 用户表(C 端用户) |
| address_book | 地址表 |
| shopping_cart | 购物车表 |
| orders | 订单表 |
| order_detail | 订单明细表 |
10.2 项目搭建
10.2.1 创建项目
创建一个 Spring Boot 项目,选择依赖:
- Spring Web
- MyBatis Framework
- MySQL Driver
- Lombok
然后手动加其他依赖。
10.2.2 pom.xml
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.2</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Druid 数据源 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.23</version>
</dependency>
<!-- FastJSON -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.76</version>
</dependency>
<!-- Commons Lang -->
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Cache -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>10.2.3 application.yml
server:
port: 8080
spring:
application:
name: reggie
datasource:
druid:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/reggie?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: root
redis:
host: localhost
port: 6379
database: 0
cache:
redis:
time-to-live: 1800000 # 缓存过期时间 30 分钟
mybatis-plus:
configuration:
map-underscore-to-camel-case: true # 下划线转驼峰
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # SQL 日志
global-config:
db-config:
id-type: assign_id # 主键策略:雪花算法
reggie:
path: D:\reggie\img\ # 图片上传路径10.2.4 启动类
@Slf4j
@SpringBootApplication
@ServletComponentScan // 扫描 Servlet 组件(过滤器等)
@EnableTransactionManagement // 开启事务
public class ReggieApplication {
public static void main(String[] args) {
SpringApplication.run(ReggieApplication.class, args);
log.info("项目启动成功...");
}
}三个注解:
@SpringBootApplication:Spring Boot 核心注解@ServletComponentScan:扫描 @WebFilter、@WebServlet 等注解@EnableTransactionManagement:开启事务管理
10.3 公共基础模块
做项目之前,先把公共基础类准备好,后面开发就方便了。
10.3.1 统一返回结果 R
所有接口的返回结果都统一成一个格式,前端好处理。
@Data
public class R<T> {
private Integer code; // 编码:1 成功,0 失败
private String msg; // 错误信息
private T data; // 数据
private Map map = new HashMap(); // 动态数据
// 成功
public static <T> R<T> success(T object) {
R<T> r = new R<T>();
r.data = object;
r.code = 1;
return r;
}
// 失败
public static <T> R<T> error(String msg) {
R r = new R();
r.msg = msg;
r.code = 0;
return r;
}
// 追加动态数据
public R<T> add(String key, Object value) {
this.map.put(key, value);
return this;
}
}统一格式的好处:
- 前端处理方便,不用每种接口写一套解析逻辑
- 结构清晰,code 表示成功失败,data 是数据,msg 是错误信息
- 便于全局异常处理
企业项目基本都有统一返回结果。 这是规范,一定要有。
10.3.2 自定义异常
业务异常用自定义异常,方便全局处理。
public class CustomException extends RuntimeException {
public CustomException(String message) {
super(message);
}
}10.3.3 全局异常处理
所有异常统一处理,不用每个方法都 try-catch。
@ControllerAdvice(annotations = {RestController.class, Controller.class})
@ResponseBody
@Slf4j
public class GlobalExceptionHandler {
/**
* 处理 SQL 唯一约束异常(重复添加)
*/
@ExceptionHandler(SQLIntegrityConstraintViolationException.class)
public R<String> exceptionHandler(SQLIntegrityConstraintViolationException ex) {
log.error(ex.getMessage());
if (ex.getMessage().contains("Duplicate entry")) {
String[] split = ex.getMessage().split(" ");
String msg = split[2] + " 已存在";
return R.error(msg);
}
return R.error("未知错误");
}
/**
* 处理自定义业务异常
*/
@ExceptionHandler(CustomException.class)
public R<String> exceptionHandler(CustomException ex) {
log.error(ex.getMessage());
return R.error(ex.getMessage());
}
}全局异常处理的好处:
- 代码简洁,不用到处写 try-catch
- 异常处理统一,返回格式一致
- 便于记录日志
@ControllerAdvice+@ExceptionHandler是 Spring MVC 提供的全局异常处理机制。 非常实用,一定要掌握。
10.3.4 自动填充 MyMetaObjectHandler
MyBatis-Plus 的自动填充功能,插入和更新时自动填充某些字段。
比如:创建时间、更新时间、创建人、更新人。
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
/**
* 插入操作,自动填充
*/
@Override
public void insertFill(MetaObject metaObject) {
metaObject.setValue("createTime", LocalDateTime.now());
metaObject.setValue("updateTime", LocalDateTime.now());
metaObject.setValue("createUser", BaseContext.getCurrentId());
metaObject.setValue("updateUser", BaseContext.getCurrentId());
}
/**
* 更新操作,自动填充
*/
@Override
public void updateFill(MetaObject metaObject) {
metaObject.setValue("updateTime", LocalDateTime.now());
metaObject.setValue("updateUser", BaseContext.getCurrentId());
}
}实体类上要加注解:
@Data
public class Employee {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String username;
private String name;
private String password;
private String phone;
private String sex;
private String idNumber; // 身份证号
private Integer status;
@TableField(fill = FieldFill.INSERT) // 插入时填充
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE) // 插入和更新时都填充
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT)
private Long createUser;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateUser;
}自动填充太方便了!不用每个方法都去 set 创建时间、更新时间。 这就是框架的好处,帮你做重复的工作。
10.3.5 线程上下文 BaseContext
问题:自动填充的时候,怎么拿到当前登录用户的 ID?
Service 层、Mapper 层没有 request,拿不到 session。
解决方案:用 ThreadLocal。
ThreadLocal 是线程级别的变量,同一个线程里共享数据。
public class BaseContext {
private static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
// 设置当前用户 ID
public static void setCurrentId(Long id) {
threadLocal.set(id);
}
// 获取当前用户 ID
public static Long getCurrentId() {
return threadLocal.get();
}
}工作流程:
- 请求进来,过滤器里拿到用户 ID,set 到 ThreadLocal
- Service 层、Mapper 层需要的时候,从 ThreadLocal get
- 请求结束,线程销毁,ThreadLocal 也清了
同一个请求是同一个线程处理的,所以 ThreadLocal 里的数据整个请求过程都能拿到。 这是 Web 项目里常用的技巧。
10.3.6 登录过滤器 LoginCheckFilter
所有请求都要检查登录状态,没登录的拦截。
用 Filter 实现,统一处理。
@WebFilter(filterName = "loginCheckFilter", urlPatterns = "/*")
public class LoginCheckFilter implements Filter {
// 路径匹配器,支持通配符
public static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();
@Override
public void doFilter(ServletRequest servletRequest,
ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
// 1. 获取本次请求的 URI
String requestURI = request.getRequestURI();
// 2. 定义不需要处理的请求路径
String[] urls = new String[]{
"/employee/login",
"/employee/logout",
"/backend/**",
"/front/**",
"/common/**",
"/user/sendMsg",
"/user/login"
};
// 3. 判断本次请求是否需要处理
boolean check = check(urls, requestURI);
// 4. 如果不需要处理,直接放行
if (check) {
filterChain.doFilter(request, response);
return;
}
// 5-1. 判断后台员工登录状态
if (request.getSession().getAttribute("employee") != null) {
Long empId = (Long) request.getSession().getAttribute("employee");
BaseContext.setCurrentId(empId); // 放到 ThreadLocal
filterChain.doFilter(request, response);
return;
}
// 5-2. 判断用户端登录状态
if (request.getSession().getAttribute("user") != null) {
Long userId = (Long) request.getSession().getAttribute("user");
BaseContext.setCurrentId(userId); // 放到 ThreadLocal
filterChain.doFilter(request, response);
return;
}
// 6. 如果未登录,返回未登录结果
response.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN")));
}
/**
* 路径匹配
*/
public boolean check(String[] urls, String requestURI) {
for (String url : urls) {
boolean match = PATH_MATCHER.match(url, requestURI);
if (match) {
return true;
}
}
return false;
}
}关键点:
@WebFilter声明过滤器AntPathMatcher路径匹配,支持通配符- 两种用户都检查:员工(employee)和用户(user)
- 把用户 ID 放到 ThreadLocal,后面用
- 未登录返回 JSON,前端判断跳转
过滤器是 Java Web 的基础,前面第 3 章学过的。 这里就是实际应用。
10.3.7 JSON 序列化配置
问题 1:Long 类型的 ID,前端 JS 处理会丢失精度。
- JS 的 Number 最大安全整数是 2^53-1
- Java 的 Long 最大是 2^63-1
- 雪花算法生成的 ID 是 19 位,超过 JS 的安全范围
解决方案:Long 转成字符串返回。
问题 2:日期格式不统一。
解决方案:统一日期格式。
public class JacksonObjectMapper extends ObjectMapper {
public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";
public JacksonObjectMapper() {
super();
// 收到未知属性时不报异常
this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);
SimpleModule simpleModule = new SimpleModule()
// 反序列化(JSON → Java)
.addDeserializer(LocalDateTime.class,
new LocalDateTimeDeserializer(
DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
.addDeserializer(LocalDate.class,
new LocalDateDeserializer(
DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
// 序列化(Java → JSON)
.addSerializer(Long.class, ToStringSerializer.instance) // Long 转字符串
.addSerializer(LocalDateTime.class,
new LocalDateTimeSerializer(
DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
.addSerializer(LocalDate.class,
new LocalDateSerializer(
DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)));
this.registerModule(simpleModule);
}
}配置到 MVC 里:
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
/**
* 静态资源映射
*/
@Override
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/backend/**")
.addResourceLocations("classpath:/backend/");
registry.addResourceHandler("/front/**")
.addResourceLocations("classpath:/front/");
}
/**
* 扩展消息转换器
*/
@Override
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
MappingJackson2HttpMessageConverter messageConverter =
new MappingJackson2HttpMessageConverter();
// 设置自定义的 ObjectMapper
messageConverter.setObjectMapper(new JacksonObjectMapper());
// 加到最前面,优先使用
converters.add(0, messageConverter);
}
}Long 转字符串这个坑,很多新手都会踩。 前端拿到的 ID 跟后端不一样,就是精度丢失了。 这个解决方案一定要记住。
10.3.8 MyBatis-Plus 配置
@Configuration
public class MybatisPlusConfig {
/**
* 分页插件
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
}分页插件要配置,不然分页功能不好使。
10.3.9 文件上传下载
图片上传下载是通用功能,抽成 CommonController。
@RestController
@RequestMapping("/common")
public class CommonController {
@Value("${reggie.path}")
private String basePath;
/**
* 图片上传
*/
@PostMapping("/upload")
public R<String> upload(MultipartFile file) {
// 原始文件名
String originalFilename = file.getOriginalFilename();
// 后缀
String suffix = originalFilename.substring(
originalFilename.lastIndexOf("."));
// UUID 生成新文件名,防止重名覆盖
String fileName = UUID.randomUUID().toString() + suffix;
// 目录不存在就创建
File dir = new File(basePath);
if (!dir.exists()) {
dir.mkdirs();
}
try {
// 转存到指定位置
file.transferTo(new File(basePath + fileName));
} catch (IOException e) {
e.printStackTrace();
}
return R.success(fileName);
}
/**
* 图片下载
*/
@GetMapping("/download")
public void download(String name, HttpServletResponse response) {
try {
// 输入流,读文件
FileInputStream fis = new FileInputStream(
new File(basePath + name));
// 输出流,写回浏览器
ServletOutputStream os = response.getOutputStream();
response.setContentType("image/jpeg");
byte[] bytes = new byte[1024];
int len = 0;
while ((len = fis.read(bytes)) != -1) {
os.write(bytes, 0, len);
os.flush();
}
os.close();
fis.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}文件上传下载是 Web 项目的常见功能。 第 3 章学过的,这里就是实际应用。
10.4 后台管理 — 员工管理
基础搭好了,开始做第一个功能:员工管理。
10.4.1 员工登录
@RestController
@RequestMapping("/employee")
@Slf4j
public class EmployeeController {
@Autowired
private EmployeeService employeeService;
/**
* 员工登录
*/
@PostMapping("/login")
public R<Employee> login(HttpServletRequest request,
@RequestBody Employee employee) {
// 1. 密码 MD5 加密
String password = employee.getPassword();
password = DigestUtils.md5DigestAsHex(password.getBytes());
// 2. 根据用户名查数据库
LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Employee::getUsername, employee.getUsername());
Employee emp = employeeService.getOne(queryWrapper);
// 3. 用户名或密码不对
if (emp == null || !emp.getPassword().equals(password)) {
return R.error("用户名或密码错误!");
}
// 4. 账号被禁用
if (emp.getStatus() == 0) {
return R.error("账号已禁用");
}
// 5. 登录成功,把员工 ID 存到 Session
request.getSession().setAttribute("employee", emp.getId());
return R.success(emp);
}
/**
* 员工退出
*/
@PostMapping("/logout")
public R<String> logout(HttpServletRequest request) {
// 清除 Session
request.getSession().removeAttribute("employee");
return R.success("退出成功");
}
}登录流程:
- 接收用户名密码
- 密码 MD5 加密
- 根据用户名查用户
- 比对密码
- 检查状态
- 登录成功,存 Session
这里用的是 Session 登录,传统项目常用。 前后端分离项目一般用 JWT。
10.4.2 新增员工
@PostMapping
public R<String> save(@RequestBody Employee employee) {
// 初始密码 123456,MD5 加密
employee.setPassword(DigestUtils.md5DigestAsHex("123456".getBytes()));
// 这些都自动填充了,不用手动 set
// employee.setCreateTime(LocalDateTime.now());
// employee.setUpdateTime(LocalDateTime.now());
// employee.setCreateUser(BaseContext.getCurrentId());
// employee.setUpdateUser(BaseContext.getCurrentId());
employeeService.save(employee);
return R.success("新增员工成功");
}有了自动填充,代码简洁多了。 新增、修改都不用手动 set 时间和人。
10.4.3 员工分页查询
@GetMapping("/page")
public R<Page> page(int page, int pageSize, String name) {
// 分页构造器
Page<Employee> pageInfo = new Page<>(page, pageSize);
// 条件构造器
LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
// 模糊查询
queryWrapper.like(StringUtils.isNotEmpty(name), Employee::getName, name);
// 按更新时间倒序
queryWrapper.orderByDesc(Employee::getUpdateTime);
// 执行查询
employeeService.page(pageInfo, queryWrapper);
return R.success(pageInfo);
}分页查询是 MyBatis-Plus 的标配。 Page 对象 + LambdaQueryWrapper,非常方便。
10.4.4 编辑员工
@PutMapping
public R<String> update(@RequestBody Employee employee) {
// updateTime 和 updateUser 自动填充
employeeService.updateById(employee);
return R.success("员工信息修改成功");
}就这么简单!
10.5 后台管理 — 分类管理
分类管理:菜品分类、套餐分类。
10.5.1 新增分类
@PostMapping
public R<String> save(@RequestBody Category category) {
categoryService.save(category);
return R.success("新增分类成功");
}10.5.2 分类分页
@GetMapping("/page")
public R<Page> page(int page, int pageSize) {
Page<Category> pageInfo = new Page<>(page, pageSize);
LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.orderByAsc(Category::getSort);
categoryService.page(pageInfo, queryWrapper);
return R.success(pageInfo);
}10.5.3 删除分类(重点)
删除分类的时候要检查:
- 分类下面有没有菜品?有就不能删
- 分类下面有没有套餐?有就不能删
这就是业务逻辑,要在 Service 层处理。
@Service
public class CategoryServiceImpl extends ServiceImpl<CategoryMapper, Category>
implements CategoryService {
@Autowired
private DishService dishService;
@Autowired
private SetmealService setmealService;
/**
* 根据 ID 删除分类,删除前检查
*/
@Override
public void remove(Long id) {
// 1. 查询当前分类是否关联了菜品
LambdaQueryWrapper<Dish> dishQueryWrapper = new LambdaQueryWrapper<>();
dishQueryWrapper.eq(Dish::getCategoryId, id);
int count1 = dishService.count(dishQueryWrapper);
if (count1 > 0) {
// 有关联菜品,抛异常
throw new CustomException("当前分类下关联了菜品,不能删除");
}
// 2. 查询当前分类是否关联了套餐
LambdaQueryWrapper<Setmeal> setmealQueryWrapper = new LambdaQueryWrapper<>();
setmealQueryWrapper.eq(Setmeal::getCategoryId, id);
int count2 = setmealService.count(setmealQueryWrapper);
if (count2 > 0) {
throw new CustomException("当前分类下关联了套餐,不能删除");
}
// 3. 正常删除
super.removeById(id);
}
}Controller 层:
@DeleteMapping
public R<String> delete(Long id) {
categoryService.remove(id);
return R.success("分类信息删除成功");
}这个设计很好:
- Controller 层很薄,只管调用
- 业务逻辑都在 Service 层
- 用自定义异常来处理业务错误
- 全局异常处理器统一返回
10.6 后台管理 — 菜品管理
菜品管理是比较复杂的一个模块,因为菜品还有口味(DishFlavor)。
10.6.1 DTO 模式
问题:新增菜品的时候,既要存菜品基本信息,又要存口味。
- Dish 表里存菜品信息
- DishFlavor 表里存口味
- 一个菜品对应多个口味
前端传过来的是一个对象,里面既有菜品信息,又有口味列表。
解决方案:用 DTO(Data Transfer Object,数据传输对象)。
@Data
public class DishDto extends Dish {
// 口味列表
private List<DishFlavor> flavors = new ArrayList<>();
// 分类名称
private String categoryName;
// 份数
private Integer copies;
}DTO 继承了 Dish,所以有 Dish 的所有属性,再扩展一些。 前端传过来的 JSON 可以直接封装成 DishDto。
10.6.2 新增菜品
Service 层处理,涉及两张表,要加事务。
@Service
@Transactional
public class DishServiceImpl extends ServiceImpl<DishMapper, Dish>
implements DishService {
@Autowired
private DishFlavorService dishFlavorService;
/**
* 新增菜品,同时保存口味
*/
@Override
public void saveWithFlavor(DishDto dishDto) {
// 1. 保存菜品基本信息
this.save(dishDto);
Long dishId = dishDto.getId();
// 2. 给口味设置菜品 ID
List<DishFlavor> flavors = dishDto.getFlavors();
flavors = flavors.stream().map((item) -> {
item.setDishId(dishId);
return item;
}).collect(Collectors.toList());
// 3. 保存口味列表
dishFlavorService.saveBatch(flavors);
}
}Controller 层:
@PostMapping
public R<String> save(@RequestBody DishDto dishDto) {
dishService.saveWithFlavor(dishDto);
return R.success("新增菜品成功");
}
@Transactional事务注解,保证两张表要么都成功,要么都失败。 涉及多表操作一定要加事务。
10.6.3 菜品分页查询(带分类名称)
分页查询菜品,还要显示分类名称。 分类名称在 category 表,dish 表只有 category_id。
两种方式:
- 联表查询(写 SQL)
- 查出来再补(查菜品 → 循环查分类 → 拼结果)
这个项目用的是第二种方式。
@GetMapping("/page")
public R<Page> page(int page, int pageSize, String name) {
// 菜品分页
Page<Dish> pageInfo = new Page<>(page, pageSize);
// DTO 分页(返回给前端的)
Page<DishDto> dishDtoPage = new Page<>();
LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.like(name != null, Dish::getName, name);
queryWrapper.orderByDesc(Dish::getUpdateTime);
dishService.page(pageInfo, queryWrapper);
// 对象拷贝(除了 records)
BeanUtils.copyProperties(pageInfo, dishDtoPage, "records");
// 处理 records:给每个菜品补上分类名称
List<Dish> records = pageInfo.getRecords();
List<DishDto> list = records.stream().map((item) -> {
DishDto dishDto = new DishDto();
BeanUtils.copyProperties(item, dishDto);
// 根据分类 ID 查分类名称
Long categoryId = item.getCategoryId();
Category category = categoryService.getById(categoryId);
if (category != null) {
dishDto.setCategoryName(category.getName());
}
return dishDto;
}).collect(Collectors.toList());
dishDtoPage.setRecords(list);
return R.success(dishDtoPage);
}这种方式简单,不用写 SQL。 但如果数据量大,循环查数据库性能不好。 实际项目中数据量大的话,建议联表查询。
10.6.4 根据 ID 查询菜品(带口味)
@Override
public DishDto getByIdWithFlavor(Long id) {
// 1. 查询菜品基本信息
Dish dish = this.getById(id);
DishDto dishDto = new DishDto();
BeanUtils.copyProperties(dish, dishDto);
// 2. 查询口味
LambdaQueryWrapper<DishFlavor> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(DishFlavor::getDishId, id);
List<DishFlavor> flavors = dishFlavorService.list(queryWrapper);
dishDto.setFlavors(flavors);
return dishDto;
}10.6.5 修改菜品
@Override
@Transactional
public void updateWithFlavor(DishDto dishDto) {
// 1. 更新菜品基本信息
this.updateById(dishDto);
// 2. 先删掉原来的口味
LambdaQueryWrapper<DishFlavor> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(DishFlavor::getDishId, dishDto.getId());
dishFlavorService.remove(queryWrapper);
// 3. 再添加新的口味
List<DishFlavor> flavors = dishDto.getFlavors();
flavors = flavors.stream().map((item) -> {
item.setDishId(dishDto.getId());
return item;
}).collect(Collectors.toList());
dishFlavorService.saveBatch(flavors);
}修改口味的思路:先删后加。 简单粗暴,但是好用。
10.6.6 菜品起售/停售
@PostMapping("/status/{statusNum}")
public R<String> updateStatus(@PathVariable Integer statusNum,
@RequestParam List<Long> ids) {
LambdaUpdateWrapper<Dish> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.in(Dish::getId, ids);
updateWrapper.set(Dish::getStatus, statusNum);
dishService.update(updateWrapper);
return R.success(statusNum == 0 ? "停售成功" : "起售成功");
}批量修改状态,用 in 条件。
10.7 后台管理 — 套餐管理
套餐管理跟菜品管理很像,也是主表 + 关联表。
10.7.1 新增套餐
@Override
@Transactional
public void saveWithDish(SetmealDto setmealDto) {
// 1. 保存套餐基本信息
this.save(setmealDto);
Long setmealId = setmealDto.getId();
// 2. 保存套餐和菜品的关联
List<SetmealDish> setmealDishes = setmealDto.getSetmealDishes();
setmealDishes = setmealDishes.stream().map((item) -> {
item.setSetmealId(setmealId);
return item;
}).collect(Collectors.toList());
setmealDishService.saveBatch(setmealDishes);
}跟菜品新增几乎一样的套路。
10.7.2 删除套餐
删除套餐要检查状态:在售的不能删。
@Override
@Transactional
public void removeWithDish(List<Long> ids) {
// 1. 查询状态,确定是否可以删除
LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.in(Setmeal::getId, ids);
queryWrapper.eq(Setmeal::getStatus, 1); // 在售状态
int count = this.count(queryWrapper);
if (count > 0) {
throw new CustomException("套餐正在售卖中,不能删除");
}
// 2. 删除套餐表
this.removeByIds(ids);
// 3. 删除套餐菜品关联表
LambdaQueryWrapper<SetmealDish> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.in(SetmealDish::getSetmealId, ids);
setmealDishService.remove(lambdaQueryWrapper);
}套路都一样:先检查,再删除。 业务逻辑都在 Service 层。
10.8 用户端 — 用户登录
用户端是给消费者用的,登录方式不一样:手机号 + 短信验证码。
10.8.1 发送验证码
@PostMapping("/sendMsg")
public R<String> sendMsg(@RequestBody User user, HttpSession session) {
// 获取手机号
String phone = user.getPhone();
if (StringUtils.isNotEmpty(phone)) {
// 生成 4 位验证码
String code = ValidateCodeUtils.generateValidateCode(4).toString();
log.info("code={}", code);
// 把验证码存到 Session
session.setAttribute(phone, code);
// 这里应该调用短信服务发送,先模拟
return R.success("手机验证码短信发送成功");
}
return R.error("短信发送失败");
}真实项目里要对接短信服务商(阿里云、腾讯云等)。 这里先存在 Session,登录的时候比对。
10.8.2 验证码登录
@PostMapping("/login")
public R<User> login(@RequestBody Map map, HttpSession session) {
// 手机号
String phone = map.get("phone").toString();
// 验证码
String code = map.get("code").toString();
// 从 Session 取保存的验证码
Object codeInSession = session.getAttribute(phone);
// 比对验证码
if (codeInSession != null && codeInSession.equals(code)) {
// 登录成功
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getPhone, phone);
User user = userService.getOne(queryWrapper);
// 新用户自动注册
if (user == null) {
user = new User();
user.setPhone(phone);
user.setStatus(1);
userService.save(user);
}
session.setAttribute("user", user.getId());
return R.success(user);
}
return R.error("登录失败");
}登录流程:
- 用户输入手机号,点获取验证码
- 后端生成验证码,存 Session,发短信
- 用户输入验证码,提交登录
- 后端比对验证码
- 验证码对了,查用户,新用户自动注册
- 登录成功,存 Session
这种登录方式很常见,App、小程序基本都是这样。 不用记密码,方便。
10.9 用户端 — 购物车
购物车是电商项目的核心功能之一。
10.9.1 添加购物车
@PostMapping("/add")
public R<ShoppingCart> add(@RequestBody ShoppingCart shoppingCart) {
// 设置用户 ID
Long currentId = BaseContext.getCurrentId();
shoppingCart.setUserId(currentId);
Long dishId = shoppingCart.getDishId();
LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(ShoppingCart::getUserId, currentId);
if (dishId != null) {
// 添加的是菜品
queryWrapper.eq(ShoppingCart::getDishId, dishId);
} else {
// 添加的是套餐
queryWrapper.eq(ShoppingCart::getSetmealId, shoppingCart.getSetmealId());
}
// 查购物车里有没有这个商品
ShoppingCart cartOne = shoppingCartService.getOne(queryWrapper);
if (cartOne != null) {
// 已经有了,数量 +1
Integer number = cartOne.getNumber();
cartOne.setNumber(number + 1);
shoppingCartService.updateById(cartOne);
} else {
// 没有,新增,数量默认 1
shoppingCart.setNumber(1);
shoppingCart.setCreateTime(LocalDateTime.now());
shoppingCartService.save(shoppingCart);
cartOne = shoppingCart;
}
return R.success(cartOne);
}逻辑:
- 先查购物车里有没有这个商品
- 有就数量 +1
- 没有就新增一条
购物车既可以加菜品,也可以加套餐。 用 dishId 和 setmealId 区分。
10.9.2 减少购物车
@PostMapping("/sub")
public R<ShoppingCart> sub(@RequestBody ShoppingCart shoppingCart) {
Long currentId = BaseContext.getCurrentId();
shoppingCart.setUserId(currentId);
// 查询当前商品
LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(ShoppingCart::getUserId, currentId);
if (shoppingCart.getDishId() != null) {
queryWrapper.eq(ShoppingCart::getDishId, shoppingCart.getDishId());
} else {
queryWrapper.eq(ShoppingCart::getSetmealId, shoppingCart.getSetmealId());
}
ShoppingCart cartOne = shoppingCartService.getOne(queryWrapper);
Integer number = cartOne.getNumber();
if (number > 1) {
// 数量 > 1,就 -1
cartOne.setNumber(number - 1);
shoppingCartService.updateById(cartOne);
} else {
// 数量 = 1,就删掉
shoppingCartService.removeById(cartOne);
}
return R.success(cartOne);
}10.9.3 查看购物车
@GetMapping("/list")
public R<List<ShoppingCart>> list() {
LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(ShoppingCart::getUserId, BaseContext.getCurrentId());
queryWrapper.orderByAsc(ShoppingCart::getCreateTime);
List<ShoppingCart> list = shoppingCartService.list(queryWrapper);
return R.success(list);
}10.9.4 清空购物车
@DeleteMapping("/clean")
public R<String> clean() {
LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(ShoppingCart::getUserId, BaseContext.getCurrentId());
shoppingCartService.remove(queryWrapper);
return R.success("清空购物车成功");
}10.10 用户端 — 下单
下单是最复杂的功能,涉及多张表。
10.10.1 下单逻辑
下单要做的事:
- 检查用户是否登录
- 检查购物车是否为空
- 检查地址是否存在
- 计算总金额
- 插入订单表(orders)
- 插入订单明细表(order_detail)
- 扣库存(如果有的话)
- 清空购物车
@Service
@Slf4j
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Orders>
implements OrderService {
@Autowired
private ShoppingCartService shoppingCartService;
@Autowired
private UserService userService;
@Autowired
private AddressBookService addressBookService;
@Autowired
private OrderDetailService orderDetailService;
/**
* 用户下单
*/
@Override
@Transactional
public void submit(Orders orders) {
// 1. 获取当前用户 ID
Long userId = BaseContext.getCurrentId();
// 2. 查询购物车数据
LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(ShoppingCart::getUserId, userId);
List<ShoppingCart> shoppingCarts = shoppingCartService.list(queryWrapper);
if (shoppingCarts == null || shoppingCarts.size() == 0) {
throw new CustomException("购物车为空,不能下单");
}
// 3. 查询用户数据
User user = userService.getById(userId);
// 4. 查询地址数据
Long addressBookId = orders.getAddressBookId();
AddressBook addressBook = addressBookService.getById(addressBookId);
if (addressBook == null) {
throw new CustomException("地址信息有误,不能下单");
}
// 5. 生成订单号
long orderId = IdWorker.getId();
AtomicInteger amount = new AtomicInteger(0);
// 6. 组装订单明细
List<OrderDetail> orderDetails = shoppingCarts.stream().map((item) -> {
OrderDetail orderDetail = new OrderDetail();
orderDetail.setOrderId(orderId);
orderDetail.setName(item.getName());
orderDetail.setImage(item.getImage());
orderDetail.setDishId(item.getDishId());
orderDetail.setSetmealId(item.getSetmealId());
orderDetail.setDishFlavor(item.getDishFlavor());
orderDetail.setNumber(item.getNumber());
orderDetail.setAmount(item.getAmount());
// 累加金额
amount.addAndGet(item.getAmount().multiply(
new BigDecimal(item.getNumber())).intValue());
return orderDetail;
}).collect(Collectors.toList());
// 7. 组装订单
orders.setId(orderId);
orders.setOrderTime(LocalDateTime.now());
orders.setCheckoutTime(LocalDateTime.now());
orders.setStatus(2); // 待派送
orders.setAmount(new BigDecimal(amount.get())); // 总金额
orders.setUserId(userId);
orders.setNumber(String.valueOf(orderId));
orders.setUserName(user.getName());
orders.setConsignee(addressBook.getConsignee());
orders.setPhone(addressBook.getPhone());
orders.setAddress((addressBook.getProvinceName() == null ? "" : addressBook.getProvinceName())
+ (addressBook.getCityName() == null ? "" : addressBook.getCityName())
+ (addressBook.getDistrictName() == null ? "" : addressBook.getDistrictName())
+ (addressBook.getDetail() == null ? "" : addressBook.getDetail()));
// 8. 插入订单表
this.save(orders);
// 9. 插入订单明细表
orderDetailService.saveBatch(orderDetails);
// 10. 清空购物车
shoppingCartService.remove(queryWrapper);
}
}Controller 层:
@PostMapping("/submit")
public R<String> submit(@RequestBody Orders orders) {
orderService.submit(orders);
return R.success("下单成功");
}下单是电商项目的核心,逻辑比较复杂。 涉及多张表,一定要加事务。
10.11 项目优化与扩展
10.11.1 缓存优化
有些数据不经常变,比如菜品、套餐、分类,可以加缓存。
用 Spring Cache + Redis。
开启缓存:
@SpringBootApplication
@EnableCaching // 开启缓存
public class ReggieApplication {
// ...
}菜品列表加缓存:
@Cacheable(value = "dishCache", key = "#categoryId")
public List<DishDto> list(Long categoryId) {
// ... 查询逻辑
}新增、修改、删除时清缓存:
@CacheEvict(value = "dishCache", allEntries = true)
public void saveWithFlavor(DishDto dishDto) {
// ...
}缓存可以大大提高性能,减少数据库压力。 第 6 章学过的,这里就是实际应用。
10.11.2 读写分离
如果项目访问量大,可以做读写分离:
- 写操作走主库
- 读操作走从库
用 ShardingSphere 或者 MyCat 都可以实现。
10.11.3 前后端分离
这个项目是服务端渲染 + 静态页面的模式。 现在更流行前后端分离:
- 前端:Vue / React
- 后端:RESTful API
- 认证:JWT
10.11.4 分布式部署
项目大了,一个实例扛不住,要集群部署:
- Nginx 负载均衡
- Redis 共享 Session
- 分布式锁
- 消息队列解耦
10.12 企业最佳实践
10.12.1 代码规范
- 分层清晰:Controller / Service / Mapper,各司其职
- 命名规范:见名知意,驼峰命名
- 统一返回:所有接口返回统一格式
- 全局异常:统一处理异常
- 参数校验:前端传的参数要校验
- 日志规范:关键操作记日志
10.12.2 安全
- 密码加密:密码不能明文存
- SQL 注入:用参数化查询,不要拼接 SQL
- XSS 防护:前端输出转义
- 权限控制:后台接口要做权限校验
- 越权检查:用户只能操作自己的数据
10.12.3 性能
- 缓存:热点数据加缓存
- 分页:列表查询一定要分页
- 索引:常用查询字段加索引
- 异步:耗时操作异步处理
- 批量操作:批量插入、批量更新
10.12.4 可维护性
- 注释:复杂逻辑加注释
- 常量抽取:魔法值要定义成常量
- 配置外置:可变的配置放到配置文件
- 接口文档:用 Swagger 等工具生成接口文档
10.13 本章小结
恭喜你!瑞吉外卖项目学完了!
这一章我们做了一个完整的外卖项目,把前面学的知识都综合运用了。
你都学了什么
项目基础:
- 项目结构和技术栈
- 环境搭建
- 数据库设计
公共基础模块:
- 统一返回结果 R
- 全局异常处理
- 自动填充(MyBatis-Plus)
- 线程上下文(ThreadLocal)
- 登录过滤器
- JSON 序列化配置(Long 转字符串、日期格式化)
- 文件上传下载
- MyBatis-Plus 分页插件
后台管理:
- 员工管理(登录、退出、增删改查、分页)
- 分类管理(增删改查、删除前检查)
- 菜品管理(DTO、口味、起售停售、批量操作)
- 套餐管理(套餐菜品关联)
- 订单管理
用户端:
- 短信验证码登录
- 地址管理
- 菜品展示
- 购物车(添加、减少、清空)
- 下单
项目优化:
- 缓存优化
- 读写分离
- 前后端分离
- 分布式部署
最佳实践:
- 代码规范
- 安全
- 性能
- 可维护性
项目中的设计亮点
- 统一返回结果:所有接口格式一致,前端好处理
- 全局异常处理:代码简洁,统一处理
- 自动填充:不用手动 set 创建时间、更新时间
- ThreadLocal:同一线程共享用户信息
- DTO 模式:前后端数据传输更灵活
- Service 层业务逻辑:Controller 层很薄
- 自定义异常:业务错误统一抛异常
- 过滤器登录检查:统一处理登录校验
给实习生的建议
先整体后细节:先搞清楚项目整体结构,再逐个模块深入。
多思考为什么:不要只看代码怎么写,要想为什么这么设计。比如为什么用 DTO?为什么要分层?
动手敲一遍:看十遍不如敲一遍。自己敲一遍,理解会深很多。
调试很重要:遇到问题,用 Debug 一步步跟,看数据是怎么流转的。
规范很重要:企业项目不是一个人写的,规范的代码别人才看得懂,才好维护。
安全意识:写代码的时候要考虑安全问题,不能有漏洞。
性能意识:写 SQL、做查询的时候,要想想性能好不好,数据量大了会不会慢。
到这里,Spring Boot 企业级开发的主要内容就学完了! 从入门到项目实战,相信你已经掌握了 Spring Boot 开发的核心技能。
接下来就是多练、多做项目,在实践中不断提升。
加油!未来的架构师就是你!